查看原文
其他

快速掌握并发编程---synchronized篇(上)

田老师 Java后端技术全栈 2021-08-29

关注Java后端技术全栈

回复“000”获取大量电子书

昨天我们聊了并发编程的基础篇快速掌握并发编程---基础篇今天我们继续聊并发编程的synchronized

谈到线程可能都会想到线程安全问题,想到线程安全,可能会联想到synchronized这个关键字。这也是工作中或者面试中很重要的一个知识点。

相信不少同学在工作中使用过这个synchronized(同步锁)。

下面来个面试连环炮:

  1. 什么业务场景下使用了同步锁?

  2. 使用他有什么好处?

  3. 会存在问题吗?

  4. 有更好的方法替换吗?

  5. 什么叫做线程安全?

  6. 如果让你来设计一个同步锁,你会怎么设计?

何为线程安全?

我们经常会听说某个类是线程安全,某个类不是线程安全的。那么究竟什么叫做线程安全呢?

我们引用《Java Concurrency in Practice》里面的定义:

在不使用额外同步的情况下,多个线程访问一个对象时,不论线程之间如何交替执行或者在调用方进行任何其它的协调操作,调用这个对象的行为都能得到正确的结果,那么这个对象是线程安全的。

也可以这么理解:

多个线程访问同一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他操作,调用这个对象的行为都可以获得正确的结果,那么这个对象就是线程安全的。


或者说:一个类或者程序所提供的接口对于线程来说是原子操作或者多个线程之间的切换不会导致该接口的执行结果存在二义性,也就是说我们不用考虑同步的问题。

可以简单的理解为:“你随便怎么调用,出了问题算我输”。

这个定义对于类来说是十分严格的,即使是Java API中标为线程安全的类也很难满足这个要求。

比如Vector是标记为线程安全的,但实际上并不能满足这个条件,举个例子:

1public class Vector<E>  extends AbstractList<E>  implements List<E>, RandomAccess, Cloneable, java.io.Serializable{
2    public synchronized E get(int index) {
3        if (index >= elementCount)
4            throw new ArrayIndexOutOfBoundsException(index);
5        return elementData(index);
6    }
7    public synchronized void removeElementAt(int index) {
8        modCount++;
9        if (index >= elementCount) {
10            throw new ArrayIndexOutOfBoundsException(index + " >= " +
11                                                     elementCount);
12        }
13        else if (index < 0) {
14            throw new ArrayIndexOutOfBoundsException(index);
15        }
16        int j = elementCount - index - 1;
17        if (j > 0) {
18            System.arraycopy(elementData, index + 1, elementData, index, j);
19        }
20        elementCount--;
21        elementData[elementCount] = null/* to let gc do its work */
22    }
23    //....基本上所有方法都是synchronized修饰的
24}   

来看下面一个案例:

判断Vector中第0个元素是不是空字符,如果是空字符就将其删除。

1package com.java.tian.blog.utils;
2
3import java.util.Vector;
4
5public class SynchronizedDemo{
6    static Vector<String> vct = new Vector<String>();
7    public  void remove() {
8        if("".equals(vct.get(0))) {
9            vct.remove(0);
10        }
11    }
12
13    public static void main(String[] args) {
14        vct.add("");
15        SynchronizedDemo synchronizedDemo = new SynchronizedDemo();
16        new Thread(new Runnable() {
17            @Override
18            public void run() {
19                synchronizedDemo.remove();
20            }
21        },"线程1").start();
22        new Thread(new Runnable() {
23            @Override
24            public void run() {
25                synchronizedDemo.remove();
26            }
27        },"线程2").start();
28
29    }
30}

上面的逻辑看起来没有瑕疵,实际上是有可能导致错误的。假设第0个元素是空字符,判断的时候得到的结果是true。

两个线程同时执行上面的remove方法,(极端的情况)都可能get到的是"",然后都去删除第0个元素,这个元素有可能已经被其它线程删除了,因此Vector不是绝对线程安全的。(上面这个案例只是做演示而已,在你的业务代码里面这么写的话,线程安全真的就不能靠Vector来保证了)。

通常情况下我们说的线程安全都是相对线程安全,相对线程安全只要求调用单个方法的时候不需要同步就可以得到正确的结果,但是多个方法组合调用的时候也是有可能导致多线程问题的。如果想让上面的操作执行正确我们需要在调用Vector方法的时候添加额外的同步操作:

1package com.java.tian.blog.utils;
2
3import java.util.Vector;
4
5public class SynchronizedDemo {
6    static Vector<String> vct = new Vector<String>();
7
8    public void remove() {
9        synchronized (vct) {
10        //synchronized (SynchronizedDemo.class) {
11            if ("".equals(vct.get(0))) {
12                vct.remove(0);
13            }
14        }
15    }
16
17    public static void main(String[] args) {
18        vct.add("");
19        SynchronizedDemo synchronizedDemo = new SynchronizedDemo();
20        new Thread(new Runnable() {
21            @Override
22            public void run() {
23                synchronizedDemo.remove();
24            }
25        }, "线程1").start();
26        new Thread(new Runnable() {
27            @Override
28            public void run() {
29                synchronizedDemo.remove();
30            }
31        }, "线程2").start();
32    }
33}

根据Vector的源代码可知:Vector的每个方法都使用了synchronized关键字修饰,因此锁对象就是这个对象本身。在上面的代码中我们尝试获取的也是vct对象的锁,可以和vct对象的其它方法互斥,因此这样做可以保证得到正确的结果。

如果Vector内部使用的是其它锁同步的,并封装了锁对象,那么我们无论如何都无法正确执行这个“先判断后修改”的操作。假设被封装的对象锁为obj,get()和remove()方法对应的锁都是obj,而整个操作过程获取的是vct的锁,一个线程调用get()方法成功后就释放了obj的锁,这时这个线程只持有vct的锁,而其它线程可以获得obj的锁并抢先一步删除了第0个元素。

Java为开发者提供了很多强大的工具类,这些工具类里面有的是线程安全的,有的不是线程安全的。在这里我们列举几个面试常考的:

线程安全的类:Vector、Hashtable、StringBuffer

非线程安全的类:ArrayList、HashMap、StringBuilder

有人可能会反问:为什么Java不把所有的类都设计成线程安全的呢?这样对于我们开发者来说岂不是更爽吗?我们就不用考虑什么线程安全问题了。

事情都是具有两面性的,获得线程安全但是性能会有所下降,毕竟锁的开销是摆在那里的。线程不安全但是性能会有所提升。具体场景还得看业务更偏向于哪一个。

一个问题引发的思考

1public class SynchronizedDemo {
2
3    static int count;
4
5    public void incre() {
6        try {
7            //每个线程都睡一会,模仿业务代码
8            Thread.sleep(100 );
9        } catch (InterruptedException e) {
10            e.printStackTrace();
11        }
12        count++;
13    }
14
15    public static void main(String[] args) {
16        SynchronizedDemo synchronizedDemo = new SynchronizedDemo();
17
18        for (int i = 0; i < 1000; i++) {
19            new Thread(new Runnable() {
20                @Override
21                public void run() {
22                    synchronizedDemo.incre();
23                }
24            }).start();
25        }
26        try {
27            //让主线程等待所有线程执行完毕
28            Thread.sleep(2000L);
29        } catch (InterruptedException e) {
30            e.printStackTrace();
31        }
32        System.out.println(count);
33    }
34}

上面这段代码输出的结果是不确定的,结果是小于等于1000。

1000线程都去对count进行++操作。

如何使用同步锁?

在Java中有个说法叫做“万事万物皆对象”。synchronized就是基于对象来做文章的,与其称之为同步锁还不如叫它对象锁。

synchronized常用三种使用方式:

  • 修饰普通方法(实例方法)

  • 静态同步方法

  • 代码块

修饰普通方法(实例方法)

多个对象多把锁
1public class SynchronizedDemo {
2
3    static int count;
4
5    public synchronized void incre() {
6        try {
7            Thread.sleep(1);
8        } catch (InterruptedException e) {
9            e.printStackTrace();
10        }
11        count++;
12    }
13
14    public static void main(String[] args) {
15        for (int i = 0; i < 1000; i++) {
16            SynchronizedDemo synchronizedDemo = new SynchronizedDemo();
17            new Thread(new Runnable() {
18                @Override
19                public void run() {
20                    synchronizedDemo.incre();
21                }
22            }).start();
23        }
24        try {
25            Thread.sleep(2000L);
26        } catch (InterruptedException e) {
27            e.printStackTrace();
28        }
29        System.out.println(count);
30    }
31}

输出

1996

这里输出的结果是小于等于1000。证明了,进去test方法没有收到同步锁synchronized的管控。证明synchronized锁失败。原因就是多个对象多把锁造成的。因为每次都是重新new一个SynchronizedDemo对象。这里就叫做多个对象多把锁。

一个对象一把锁

把上面代码进行一点点调整

1public class SynchronizedDemo {
2
3    static int count;
4
5    public synchronized void incre() {
6        try {
7            Thread.sleep(1);
8        } catch (InterruptedException e) {
9            e.printStackTrace();
10        }
11        count++;
12    }
13
14    public static void main(String[] args) {
15        //只有一个对象
16        SynchronizedDemo synchronizedDemo = new SynchronizedDemo();
17        for (int i = 0; i < 1000; i++) {
18            new Thread(new Runnable() {
19                @Override
20                public void run() {
21                    synchronizedDemo.incre();
22                }
23            }).start();
24        }
25        try {
26            Thread.sleep(2000L);
27        } catch (InterruptedException e) {
28            e.printStackTrace();
29        }
30        System.out.println(count);
31    }
32}

输出

11000

这时候的synchronized同步锁就有效了。后面的线程必须等前面线程执行完了在执行。

修饰静态同步方法

也就是上面的代码稍作调整

1public class SynchronizedDemo {
2
3    static int count;
4
5    public static synchronized void incre() {
6        try {
7            Thread.sleep(1);
8        } catch (InterruptedException e) {
9            e.printStackTrace();
10        }
11        count++;
12    }
13
14    public static void main(String[] args) {
15
16        for (int i = 0; i < 1000; i++) {
17            new Thread(new Runnable() {
18                @Override
19                public void run() {
20                    incre();
21                }
22            }).start();
23        }
24        try {
25            Thread.sleep(2000L);
26        } catch (InterruptedException e) {
27            e.printStackTrace();
28        }
29        System.out.println(count);
30    }
31}

输出

11000

同步锁synchronized起到了锁的作用。多个线程同时访问静方法,线程会发生互斥(即一个线程访问,另一个线程只能等着),因为静态方法是依附于类对象而不是实例对象的,当synchronized修饰静态方法时,锁是class对象。

修饰代码块

第一种同步代码块(实例)

1public class SynchronizedDemo {
2
3    static int count;
4
5    public void incre() {
6        synchronized(this) {
7            try {
8                Thread.sleep(1);
9            } catch (InterruptedException e) {
10                e.printStackTrace();
11            }
12            count++;
13        }
14    }
15
16    public static void main(String[] args) {
17
18        for (int i = 0; i < 1000; i++) {
19            new Thread(new Runnable() {
20                //每个线程都自己创建一个对象
21                SynchronizedDemo synchronizedDemo = new SynchronizedDemo();
22                @Override
23                public void run() {
24                    synchronizedDemo.incre();
25                }
26            }).start();
27        }
28        try {
29            Thread.sleep(2000L);
30        } catch (InterruptedException e) {
31            e.printStackTrace();
32        }
33        System.out.println(count);
34    }
35}

输出结果不是确定的值,但是是小于等于1000的值。说明这个synchronized同步锁没起到作用。

1synchronized (this) {
2 //......
3}

这里的this只的是当前对象,但是上面main方法中我们是new了两个对象SynchronizedDemo。由此这个synchronized肯定就没有锁的作用了。

1public class SynchronizedDemo {
2
3    static int count;
4
5    public   void incre() {
6        synchronized(this) {
7            try {
8                Thread.sleep(1);
9            } catch (InterruptedException e) {
10                e.printStackTrace();
11            }
12            count++;
13        }
14    }
15
16    public static void main(String[] args) {
17        SynchronizedDemo synchronizedDemo = new SynchronizedDemo();
18        for (int i = 0; i < 1000; i++) {
19            new Thread(new Runnable() {
20                @Override
21                public void run() {
22                    synchronizedDemo.incre();
23                }
24            }).start();
25        }
26        try {
27            Thread.sleep(2000L);
28        } catch (InterruptedException e) {
29            e.printStackTrace();
30        }
31        System.out.println(count);
32    }
33}

输出

11000

共用一个对象synchronizedDemo的时候,锁住synchronizedDemo对象后,其他线程就必须得等待前一个线程执行结束。

第二种同步代码块(静态)

在对上面的同步代码块进行稍微的改造:

1public class SynchronizedDemo {
2
3    static int count;
4
5    public  void incre() {
6        synchronized(SynchronizedDemo.class) {
7            try {
8                Thread.sleep(1);
9            } catch (InterruptedException e) {
10                e.printStackTrace();
11            }
12            count++;
13        }
14    }
15
16    public static void main(String[] args) {
17        for (int i = 0; i < 1000; i++) {
18            new Thread(new Runnable() {
19                SynchronizedDemo synchronizedDemo = new SynchronizedDemo();
20                @Override
21                public void run() {
22                    synchronizedDemo.incre();
23                }
24            }).start();
25        }
26        try {
27            Thread.sleep(2000L);
28        } catch (InterruptedException e) {
29            e.printStackTrace();
30        }
31        System.out.println(count);
32    }
33}

输出

11000

同步锁起到锁的作用了。

1synchronized (SynchronizedDemo.class) {
2   //....
3}

这时候锁住的是SynchronizedDemo.class对象,class对象在整个JVM里只有一个,所以此时的synchronized是起到锁的作用了。

类加载器对 class 锁的影响

1synchronized (SynchronizedDemo.class) {
2   //....
3}

在 JVM 里,class 的唯一性是由 class 全限定名和 classloader 决定的,同一个全限定名的 class 被不同的 classloader 加载,最终的 class 对象是不一样的。下面来看一段代码

1public class SynchronizedDemo{
2
3    public  void test1() {
4        synchronized (this.getClass()) {
5            System.out.println("start-" + Thread.currentThread().getName());
6            try {
7                Thread.sleep(1000);
8            } catch (InterruptedException e) {
9                e.printStackTrace();
10            }
11            System.out.println("end-" + Thread.currentThread().getName());
12        }
13    }
14    public  void test2() {
15        synchronized (this.getClass()) {
16            System.out.println("start-" + Thread.currentThread().getName());
17            try {
18                Thread.sleep(1000);
19            } catch (InterruptedException e) {
20                e.printStackTrace();
21            }
22            System.out.println("end-" + Thread.currentThread().getName());
23        }
24    }
25
26    public static void main(String[] args) throws InterruptedException {
27        SynchronizedDemo synchronizedDemo1 = new SynchronizedDemo();
28        SynchronizedDemo synchronizedDemo2 = new SynchronizedDemo();
29        new Thread(new Runnable() {
30            @Override
31            public void run() {
32                synchronizedDemo1.test1();
33            }
34        },"线程1").start();
35        new Thread(new Runnable() {
36            @Override
37            public void run() {
38                synchronizedDemo2.test2();
39            }
40        },"线程2").start();
41    }
42}

输出

1start-线程1
2end-线程1
3start-线程2
4end-线程2

我们可以看到,通过对this.getClass() 加锁,即使咱调用的是不同的实例对象,也能达到互斥访问的效果,因为它们的 class 是相同的,竞争的是同一把锁。

当我们的类加载器使用的不是同一个的情况下,会出现不同的Class对象。下面来证明一下

自定义一个类加载器MyClassLoader

1package com.java.tian.blog.utils;
2
3import java.net.URL;
4import java.net.URLClassLoader;
5
6public class MyClassLoader extends URLClassLoader {
7    public MyClassLoader(URL[] urls) {
8        super(urls);
9    }
10
11    @Override
12    protected Class<?> loadClass(String name, boolean resolve)
13            throws ClassNotFoundException {
14        synchronized (getClassLoadingLock(name)) {
15            Class<?> c = findLoadedClass(name);
16            if (c == null) {
17                if ("com.java.tian.blog.utils.SynchronizedDemo".equals(name)) {
18                    c = findClass(name);
19                } else {
20                    return super.loadClass(name, resolve);
21                }
22            }
23            if (resolve) {
24                resolveClass(c);
25            }
26            return c;
27        }
28    }
29}

然后对SynchronizedDemo进行改造

1package com.java.tian.blog.utils;
2
3import java.io.File;
4import java.lang.reflect.Method;
5import java.net.URL;
6
7public class SynchronizedDemo{
8
9    public  void test1() {
10        synchronized (this.getClass()) {
11            System.out.println("start-" + Thread.currentThread().getName());
12            try {
13                Thread.sleep(1000);
14            } catch (InterruptedException e) {
15                e.printStackTrace();
16            }
17            System.out.println("end-" + Thread.currentThread().getName());
18        }
19    }
20    public  void test2() {
21        synchronized (this.getClass()) {
22            System.out.println("start-" + Thread.currentThread().getName());
23            try {
24                Thread.sleep(1000);
25            } catch (InterruptedException e) {
26                e.printStackTrace();
27            }
28            System.out.println("end-" + Thread.currentThread().getName());
29        }
30    }
31
32    public static void main(String[] args) throws Exception {
33        URL url = new File("E:\\bokeCode\\mblog-master\\target\\classes").toURL();
34        MyClassLoader myClassLoader = new MyClassLoader(new URL[]{url});
35        MyClassLoader myClassLoader2 = new MyClassLoader(new URL[]{url});
36
37        //分别使用myClassLoader和myClassLoader2加载
38        Class clazz1 = myClassLoader.loadClass("com.java.tian.blog.utils.SynchronizedDemo");
39        Class clazz2 = myClassLoader2.loadClass("com.java.tian.blog.utils.SynchronizedDemo");
40        Method method01 = clazz1.getMethod("test1");
41        Method method02 = clazz2.getMethod("test2");
42        new Thread(new Runnable() {
43            @Override
44            public void run() {
45                try {
46                    method01.invoke(clazz1.newInstance());
47                } catch (Exception e) {
48                    e.printStackTrace();
49                }
50            }
51        },"线程1").start();
52        new Thread(new Runnable() {
53            @Override
54            public void run() {
55                try {
56                    method02.invoke(clazz2.newInstance());
57                } catch (Exception e) {
58                    e.printStackTrace();
59                }
60            }
61        },"线程2").start();
62    }
63}

输出

1start-线程1
2start-线程2
3end-线程1
4end-线程2

发现此时的synchronized没有同步的作用了。

小总结

上面已经给出了synchronized的使用场景,以及什么时候有锁的作用,什么时候没有锁的作用。

我们在使用 Synchronized 的时候需要明确,在指定的用法下,当前的锁对象是谁?

是当前实例对象、动态实例对象、类对象。

一个对象一把锁、多个对象多把锁。

上面这里举了这么些个例子,只是为了说明使用 Synchronized 时,一定要保证锁对象的唯一性,只是 class 对象由于有类加载器的影响,较为特殊。

关于 JVM 的类加载机制有很多内容,本文的重心不在这里,也就不进行过多的讨论了。此外, Synchronized知识点相对较多,明天我们再继续聊~

推荐阅读

快速掌握并发编程---基础篇

为什么 Redis 单线程却能支撑高并发?

SpringBoot 并发登录人数控制
《Java虚拟机并发编程》.pdf

    : . Video Mini Program Like ,轻点两下取消赞 Wow ,轻点两下取消在看

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存